fp-ts 로 데이터 검증하기 3 - 데이터의 오류 확인하기
목차
지난 글에서
지난 글 에서는 데이터에 Option
을 이용해 오류 유무만을 확인했다.
이번 글에서는 정확히 어떤 오류가 있는지 확인하는 방법을 알아보자.
Either
Either
는 Option
과 비슷하지만, 오류가 발생했을 때 오류의 내용을 담을 수 있다.
Option
이 Either
의 일종이라고 생각하면 된다.
Either
는 두 가지의 타입을 받는데, 보통 성공적으로 처리된 경우를 Right
, 오류가 발생한 경우를 Left
라고 한다.
Option
은 이 중 Left
가 None
으로 고정된 Either
라고 생각하면 된다.
Either
적용
공식 문서 에 나와있는 예시를 보고 Either
를 어떻게 사용할 수 있는지 알아보자.
예를 들어 number[]
에서 첫 번째 요소를 가져와 역수를 취하는 함수를 만들어보자.
이 때 두 가지를 검사해야하는데, 배열이 비어있지 않은지, 첫 번째 요소가 0이 아닌지이다.
fp-ts
를 사용하지 않는다면 다음과 같이 구현할 수 있을 것이다.
const inverse = ( n : number ) : number => {
if (n === 0 ) {
throw new Error ( 'cannot divide by zero' )
}
return 1 / n
}
const head = ( as : Array < number >) : number => {
if (as. length === 0 ) {
throw new Error ( 'empty array' )
}
return as[ 0 ]
}
export const imperative = ( as : ReadonlyArray < number >) : string => {
try {
return `Result is ${ inverse ( head ( as )) }`
} catch ( err : any ) {
return `Error is ${ err . message }`
}
}
익숙하긴 하지만 매번 오류를 throw
하고 try-catch
로 잡아내는 것은 번거롭다.
대신 올바른 값을 Right
라는 타입을 만들어서 담아보자.
interface Right < A > {
readonly _tag : 'Right'
readonly right : A
}
const head = ( e : Right < number []> | any ) : number => {
if (e._tag === "Right" ) {
if (e.right. length === 0 ) return {}
return {
_tag: "Right"
right: e.right[ 0 ]
}
}
return {}
}
const inverse = ( e : Right < number > | any ) : Right < number > => {
if (e._tag === "Right" ) {
if (e.right === 0 ) return {}
return {
_tag: "Right"
right: 1 / e.right
}
}
return {}
}
export const useRight = ( as : ReadonlyArray < number >) : string => {
const result = inverse ( head (as))
return result._tag === "Right" ? `Result is ${ result . right }` : `Error`
}
하지만 이렇게만 하면 어디서 에러가 발생했는지 알 수 없다.
이를 위해 에러를 담기 위한 Left
타입을 만들어보자.
interface Left < E > {
readonly _tag : 'Left'
readonly left : E
}
const head = ( e : Left < string > | Right < number []>) : Left < string > | Right < number > => {
if (e._tag === "Right" ) {
if (e.right. length === 0 ) return { _tag: "Left" , left: "empty array" }
return { _tag: "Right" right: e.right[ 0 ] }
}
return e as Left < string >
}
const inverse = ( e : Left < string > | Right < number >) : Left < string > | Right < number > => {
if (e._tag === "Right" ) {
if (e.right === 0 ) return { _tag: "Left" , left: "cannot divide by zero" }
return { _tag: "Right" , right: 1 / e.right }
}
return e as Left < string >
}
export const useLeftRight = ( as : ReadonlyArray < number >) : string => {
const result = inverse ( head (as))
return result._tag === "Right" ? `Result is ${ result . right }` : `Error is ${ result . left }`
}
이제 공통적인 부분을 분리해보자.
먼저 Left
와 Right
를 만드는 함수를 만들어보자.
const left = < E >( e : E ) : Left < E > => ({ _tag: "Left" , left: e })
const right = < A >( a : A ) : Right < A > => ({ _tag: "Right" , right: a })
const head = ( e : Left < string > | Right < number []>) : Left < string > | Right < number > => {
if (e._tag === "Right" ) {
return e.right. length === 0 ? left ( "empty array" ) : right (e.right[ 0 ])
}
return e as Left < string >
}
const inverse = ( e : Left < string > | Right < number >) : Left < string > | Right < number > => {
if (e._tag === "Right" ) {
return e.right === 0 ? left ( "cannot divide by zero" ) : right ( 1 / e.right)
}
return e as Left < string >
}
그리고 if (e._tag === "Right") { ... } return e
를 chain
으로 바꿔보자.
pipe
는 fp-ts
의 pipe
함수이다.
const chain = < E , A , B >( f : ( a : A ) => Left < E > | Right < B >) => ( ma : Left < E > | Right < A >) : Left < E > | Right < B > =>
ma._tag === "Right" ? f (ma.right) : ma
const head = ( e : Left < string > | Right < number []>) : Left < string > | Right < number > => {
return ( as : number []) => as. length === 0 ? left ( "empty array" ) : right (as[ 0 ])
}
const inverse = ( e : Left < string > | Right < number >) : Left < string > | Right < number > => {
return ( n : number ) => n === 0 ? left ( "cannot divide by zero" ) : right ( 1 / n)
}
const useChain = ( as : number []) => {
const result = pipe (
as,
right,
chain (head),
chain (inverse)
)
return result._tag === "Right" ? `Result is ${ result . right }` : `Error is ${ result . left }`
}
그리고 받은 인자를 right
로 만들어주는 of
와 Left,
Right
인 경우 동작이 달라지는 match
를 만들어보자.
const of = < E , A >( a : A ) : Left < E > | Right < A > => right (a);
const match = < E , A , B >( onLeft : ( e : E ) => B , onRight : ( a : A ) => B ) => ( ma : Left < E > | Right < A >) : B =>
ma._tag === "Right" ? onRight (ma.right) : onLeft (ma.left)
const useOfMatch = ( as : number []) => pipe (
as,
of ,
chain (head),
chain (inverse),
match (
( err ) => `Error is ${ err }` ,
( head ) => `Result is ${ head }`
)
)
그리고 이 Left
와 Right
를 합친 타입 Either
를 만들어보자.
type Either < E , A > = Left < E > | Right < A >
const chain = < E , A , B >( f : ( a : A ) => Either < E , B >) => ( ma : Either < E , A >) => Either < E , B >
const head = ( e : Either < string , number []>) : Either < string , number > => {
return ( as : number []) => as. length === 0 ? left ( "empty array" ) : right (as[ 0 ])
}
const inverse = ( e : Either < string , number >) : Either < string , number > => {
return ( n : number ) => n === 0 ? left ( "cannot divide by zero" ) : right ( 1 / n)
}
const of = < E , A >( a : A ) : Either < E , A > => right (a);
const match = < E , A , B >( onLeft : ( e : E ) => B , onRight : ( a : A ) => B ) => ( ma : Either < E , A >) : B =>
ma._tag === "Right" ? onRight (ma.right) : onLeft (ma.left)
const useEither = ( as : number []) => pipe (
as,
of ,
chain (head),
chain (inverse),
match (
( err ) => `Error is ${ err }` ,
( head ) => `Result is ${ head }`
)
)
fp-ts
의 Either
를 사용하면 다음과 같이 구현할 수 있다.
import * as E from "fp-ts/Either"
const head = ( as : number []) : Either < string , number > =>
as. length === 0 ? E . left ( "empty array" ) : E . right (as[ 0 ]);
const inverse = ( n : number ) : Either < string , number > =>
n === 0 ? E . left ( "cannot divide by zero" ) : E . right ( 1 / n);
const functional = ( as : number []) =>
pipe (
as,
E .of < string, number[] > ,
E . chain (head),
E . chain (inverse),
E . match (
( err ) => `Error is ${ err }` ,
( head ) => `Result is ${ head }`
)
);
훨씬 더 간결해졌다.
여러 개의 에러 처리하기
위와 같은 경우에는 하나의 에러만 처리해도 되지만 동시에 여러가지 에러가 발생할 수도 있다.
지난 글에서 했던 UserInput
검사를 Either
로 바꿔보자.
먼저 간단하게 최소 글자수 검사만 해보자.
const isPasswordValid = ( body : Body ) =>
minLength ( 8 )(body.password) ? E . right (body) : E . left ( "password is too short" );
const isUsernameValid = ( body : Body ) =>
minLength ( 6 )(body.username) ? E . right (body) : E . left ( "username is too short" );
const isUserInputValid = ( body : Body ) =>
pipe (
body,
E .of,
E . chain (isPasswordValid),
E . chain (isUsernameValid),
);
const stringify = E . match < string [], UserInput , string >(
( err ) => `Error: ${ err . join ( ", " ) }` ,
({ username , password }) =>
`Result: username is ${ username }, password is ${ password }`
);
const exampleUserInput = [
{ username: "Giulio" , password: "password" },
{ username: "Giulio" , password: "pass" },
{ username: "Giu" , password: "password" },
{ username: "Giu" , password: "pass" },
];
console. log (exampleUserInput. map (isUserInputValid). map (stringify). join ( " \n " ));
/* Output:
Result is username: Giulio, password: password
Error is password is too short
Error is username is too short
Error is password is too short
*/
우리가 최종적으로 만들고 싶은 것은 4번째 예제에서 username
과 password
모두 에러가 발생했을 때, 두 개의 에러를 모두 출력하는 것이다.
그러기 위해서는 각각의 에러문을 배열로 합치는 것이 이상적일 것이다.
즉 다음과 반환값이 Either<string[], T>
를 합치는 함수를 만들어야한다.
| Either1
| Either2
| 결과 |
| --- | --- | --- |
| Right<A>
| Right<B>
| Right<A & B>
|
| Right<A>
| Left<B>
| Left<B>
|
| Left<A>
| Right<B>
| Left<A>
|
| Left<A>
| Left<B>
| Left<A + B>
|
이를 직접 구현한다면 다음과 같은 모습일 것이다.
const concatEithers = (
e1 : E . Either < string [], { username : string }>,
e2 : E . Either < string [], { password : string }>
) =>
E . isLeft (e1)
? E . isLeft (e2)
? E . left ([ ... e1.left, ... e2.left])
: e1
: E . isLeft (e2)
? e2
: E . right ({ ... e1.right, ... e2.right });
getApplicativeValidation
다행히 fp-ts
에서는 이를 위한 함수가 존재한다.
Either
의 getApplicativeValidation
라는 함수이다.
다만 사용법을 조금 숙지해야한다.
먼저 getApplicativeValidation
는 Semigroup
을 받아 Applicative
를 반환하는 함수이다.
입력하는 Semigroup
은 Either
의 left
에 담긴 값들을 합치는 Semigroup
이다.
반환는 Applicative
는 ap
를 통해 사용하는데 지난 글에서 설명했듯, ap
는 컨테이너에 담긴 함수와 값을 받아 함수에 값을 넣어 반환하는 함수이다.
따라서 다음과 같은 방식으로 사용해야한다.
const { ap } = E . getApplicativeValidation ( A . getMonoid < string >());
ap (
E . right (( a : number ) => a + 1 ),
E . right ( 2 )
); // Right(3)
ap (
E . right (( a : number ) => a + 1 ),
E . left ([ "error" ])
); // Left(["error"])
ap (
E . left ([ "error1" ]),
E . left ([ "error2" ])
); // Left(["error1", "error2"])
이를 이용하면 username
을 합치는 부분을 이렇게 바꿀 수 있다.
const errorsApplicative = E . getApplicativeValidation ( A . getMonoid < string >());
const validUsername = ( username : string ) =>
minLength ( 6 )(username) ? E . right (username) : E . left ([ "username too short" ]);
const validUserInput = ( input : Body ) => pipe (
...
( ea ) =>
errorsApplicative. ap (
E . isLeft (ea)
? ea
: E . right (( b : string ) => ({ ... ea.right, username: b })),
validUsername (input.username)
),
...
)
Applicative
는 Functor
이므로 map
을 사용할 수 있다.
이를 통해 더 간결하게 쓸 수 있다.
const validUserInput = ( input : Body ) => pipe (
...
( ea ) =>
errorsApplicative. ap (
errorsApplicative. map (ea, ( a ) => ( b : string ) => ({ ... a, username: b })),
validUsername (input.username)
),
...
)
하지만 이를 더 쉽게 사용할 수 있는 방법이 있다.
apS
Apply
에는 apS
라는 함수가 존재한다.
apS
는 컨테이너에 담긴 레코드 값에 새로운 성분을 추가한 레코드를 반환하는 함수이다.
세 번이나 호출을 해야하는 고차함수이나, 그만큼 편리하게 사용할 수 있다.
첫 번째 호출 시에는 컨테이너의 Apply
를 넣는다.
두 번째 호출 시에는 성분명과 해당 성분에 넣을 값이 담긴 컨테이너를 넣는다.
세 번째 호출 시에는 컨테이너에 담긴 레코드를 넣는다.
apS
의 최종 반환값의 타입은 해당 성분의 존재와 타입을 보장한다.
Applicative
는 당연히 Apply
이므로 apS
를 사용할 수 있다.
import { apS } from "fp-ts/Apply" ;
const errorsApplicative = E . getApplicativeValidation ( A . getMonoid < string >());
const errorsApS = apS (errorsApplicative);
const validUserInput = ({ username , password } : UserInput ) =>
pipe (
E . right ({}),
errorsApS ( "username" , validUsername (username)),
errorsApS ( "password" , validPassword (password)),
);
Do notation
추가적으로 E.right({})
는 자주 쓰이기 때문에 E.Do
라는 이름으로 사용할 수 있다.
const validUserInput = ({ username , password } : UserInput ) =>
pipe (
E .Do,
errorsApS ( "username" , validUsername (username)),
errorsApS ( "password" , validPassword (password)),
E . match (
( err ) => `Error is ${ err . join ( ", " ) }` ,
({ username , password }) =>
`Result is username: ${ username }, password: ${ password }`
)
);
const examples = [
{ username: "Giulio" , password: "password" },
{ username: "Giulio" , password: "pass" },
{ username: "Giu" , password: "password" },
{ username: "Giu" , password: "pass" },
];
console. log (examples. map (validUserInput). join ( " \n " ));
/* Output:
Result is username: Giulio, password: password
Error is password too short
Error is username too short
Error is username too short, password too short
*/
특히 방금 쓰였던 apS
과 bind
, exists
등의 함수는 E.Do
와 자주 쓰인다.
이와 같은 방법론을 "Do notation" 이라고 한다.
validate
이제 지난 글에서 만들었던 validate
함수를 Either
로 바꿔보자.
지난 글에서는 Option
을 사용했기 때문에 검사만 하면 됐었다.
이번 글에선 Either
로 에러 메시지를 담아야하기 때문에 검사함수 pred
와 오류 시 반환할 메시지 error
를 같이 받아야한다.
검사할 함수들을 모두 문제 없이 통과한다면 기존 값을 담고 있는 Right
를 반환해야한다.
하지만 검사 중 하나라도 문제가 있다면 문제가 있는 모든 에러문이 담긴 배열이 담긴 Left
를 반환해야한다.
간단하게 명령형으로 먼저 구현해보자.
const validate_ =
< T >( predAndError : [ Predicate < T >, string ][]) =>
( v : T ) => {
const errors : string [] = [];
for ( const [ pred , error ] of predAndError) {
if ( ! pred (v)) {
errors. push (error);
}
}
if (errors. length > 0 ) {
return E . left (errors);
}
return E . of (v);
};
눈에 보이는 것부터 천천히 바꿔보자.
먼저 for (...) { if (...) {...} }
는 filter
로 바꿀 수 있다.
그리고 그 중에서 두번째 인자인 error
를 가져오기 위해 map
을 사용하자.
const validate =
< T >( predAndError : [ Predicate < T >, string ][]) =>
( v : T ) => {
const errors = predAndError. filter (([ pred ]) => ! pred (v)). map (([, error ]) => error);
return errors. length > 0 ? E . left (errors) : E . of (v);
};
filter
과 map
에서 사용된 특정 인자를 갖고 오는 함수는 Tuple
의 fst
, snd
라는 함수로 대체할 수 있다.
import * as T from "fp-ts/Tuple" ;
const validate =
< T >( predAndError : [ Predicate < T >, string ][]) =>
( v : T ) => {
const errors = predAndError. filter (( p ) => ! T . fst (p)(v)). map ( T .snd);
return errors. length > 0 ? E . left (errors) : E . of (v);
};
부정문 !
과 인자 적용은 각각 fp-ts/function
의 not
과 apply
으로 대체할 수 있다.
import { apply, not } from "fp-ts/function" ;
const validate =
< T >( predAndError : [ Predicate < T >, string ][]) =>
( v : T ) => {
const errors = predAndError. filter (( p ) => apply (v)( not ( T . fst (p)))). map ( T .snd);
return errors. length > 0 ? E . left (errors) : E . of (v);
};
그리고 이를 flow
로 묶어 인자를 명시하지 않는 함수로 만들 수 있다.
import { flow } from "fp-ts/function" ;
const validate =
< T >( predAndError : [ Predicate < T >, string ][]) =>
( v : T ) => {
const errors = predAndError. filter ( flow ( T .fst, not, apply (v))). map ( T .snd);
return errors. length > 0 ? E . left (errors) : E . of (v);
};
여기에 Array.prototype.filter
, Array.prototype.map
을 fp-ts/Array
의 filter
, map
으로 대체히자.
길이를 확인하는 errors.length > 0
은 fp-ts/Array
의 isNonEmpty
로 대체하자.
import * as A from "fp-ts/Array" ;
const validate =
< T >( predAndError : [ Predicate < T >, string ][]) =>
( v : T ) => {
const failed = A . filter <[ Predicate < T >, string ]>( flow ( T .fst, not, apply (v)))(predAndError);
const errors = A . map ( T .snd)(failed);
return A . isNonEmpty (errors) ? E . left (errors) : E . of (v);
};
이제 반환부를 바꿔보자.
먼저 fromPredicate
를 통해 Predicate
를 Either
로 바꾸자.
const validate =
< T >( predAndError : [ Predicate < T >, string ][]) =>
( v : T ) => {
const failed = A . filter <[ Predicate < T >, string ]>( flow ( T .fst, not, apply (v)))(predAndError);
const errors = A . map ( T .snd)(failed);
return E . fromPredicate ( A .isNonEmpty, () => errors)(errors);
};
하지만 이렇게 되면 Right(v)
가 아닌 Right(errors)
가 반환된다.
생각해보면 지금 필요한 것은 조건 함수가 true
면 그 값을 Left
에 담고 아닐 경우 Right(v)
를 반환하는 함수가 필요하다.
공식 문서를 뒤져봤지만 이런 함수는 없었다.
그래서 대신 fold
를 이용했다.
Either
의 fold
는 onLeft
와 onRight
를 받아 각각의 경우 실행하는 함수이다.
const validate =
< T >( predAndError : [ Predicate < T >, string ][]) =>
( v : T ) => {
const failed = A . filter <[ Predicate < T >, string ]>( flow ( T .fst, not, apply (v)))(predAndError);
const errors = A . map ( T .snd)(failed);
const rightErrors = E . fromPredicate ( A .isNonEmpty, () => null )(errors);
return E . fold (() => E . of (v), E .left < string[], T > )(rightErrors);
};
여기에 fp-ts/function
의 constant
와 constVoid
를 사용해 인자를 받지 않는 함수를 대체하자.
constant
는 첫 인자를 받아서 저장해 둔 뒤 호출할 때마다 그 값을 반환하는 함수이다.
이 때 호출 시에 들어온 인자는 모두 무시된다.
constVoid
는 constant(undefined)
와 같은 함수이다.
import { constant, constVoid } from "fp-ts/function" ;
const validate =
< T >( predAndError : [ Predicate < T >, string ][]) =>
( v : T ) => {
const failed = A . filter <[ Predicate < T >, string ]>( flow ( T .fst, not, apply (v)))(predAndError);
const errors = A . map ( T .snd)(failed);
const rightErrors = E . fromPredicate ( A .isNonEmpty, constVoid)(errors);
return E . fold ( constant ( E . of (v)), E .left < string[], T > )(rightErrors);
};
마지막으로 pipe
를 통해 깔끔하게 만들어주자.
const validate =
< T >( predAndError : [ Predicate < T >, string ][]) =>
( v : T ) =>
pipe (
predAndError,
A . filter ( flow ( T .fst, not, apply (v))),
A . map ( T .snd),
E . fromPredicate ( A .isNonEmpty, constVoid),
E . fold ( constant ( E . of (v)), E .left < string[], T > )
);
이를 이용해 지난 글에서 만들었던 validateUsername
를 다시 만들어보자.
const validateUsername = ( username : unknown ) =>
pipe (
username,
validate ([
[ minLength ( 6 ), "username is too short" ],
[ maxLength ( 20 ), "username is too long" ],
[isAlphaNumeric, "username must be alphanumeric" ],
])
);
물론 문제가 발생할 것이다.
username
은 unknown
이지만 이를 검사하는 함수들은 string
을 받는다.
이를 해결하기 위해서는 fp-ts/string
의 isString
을 사용해 unknown
을 string
임을 보장해야한다.
import * as S from "fp-ts/string" ;
const validateUsername = ( username : unknown ) =>
pipe (
username,
validate ([
[ S .isString, "username must be a string" ],
[ minLength ( 6 ), "username is too short" ],
[ maxLength ( 20 ), "username is too long" ],
[isAlphaNumeric, "username must be alphanumeric" ],
])
);
여전히 문제가 발생한다.
S.isString
을 통과하지 못하면 username
은 unknown
이므로 다른 검사를 진행해서는 안 된다.
하지만 validate
는 각각의 검사를 독립적으로 시행하기 때문에 S.isString
을 통과하지 못해도 다른 검사를 진행한다.
그렇기 떄문에 먼저 S.isString
검사는 따로 진행한 뒤, 통과 후 다른 검사를 진행해야한다.
그리고 그 결과값은 Either
이므로 flatMap
을 통해 다른 검사를 시행해야한다.
const validateUsername = ( username : unknown ) =>
pipe (
username,
validate ([[ S .isString, "username must be a string" ]]),
E . flatMap (
validate ([
[ minLength ( 6 ), "username is too short" ],
[ maxLength ( 20 ), "username is too long" ],
[isAlphaNumeric, "username must be alphanumeric" ],
])
)
);
하지만 여전히 문제가 발생할 것이다.
이는 validate
의 predAndError
타입이 [Predicate<T>, string][]
인데 S.isString
는 Refinment<unknown, string>
이기 때문이다.
따라서 validate
를 Refinment
도 받을 수 있도록 바꿔주자.
const validate =
< U , T extends U >( predAndError : [ Predicate < T > | Refinement < U , T >, string ][]) =>
( v : U ) =>
pipe (
predAndError,
A . filter ( flow ( T .fst, not, apply (v as T ))),
A . map ( T .snd),
E . fromPredicate ( A .isNonEmpty, constVoid),
E . fold ( constant ( E . of (v as T )), E .left < string[], T > )
);
그럼 문제없이 동작할 것이다.
이제 마찬가지로 validatePassword
를 만들어보자.
const validatePassword = flow (
validate ([[ S .isString, "password must be a string" ]]),
E . chain (
validate ([
[ minLength ( 8 ), "password is too short" ],
[ maxLength ( 20 ), "password is too long" ],
[hasAlphaAndNumeric, "password has at least one letter and one number" ],
])
)
);
이를 통해 validateUserInput
를 만들면 다음과 같을 것이다.
const validUserInput = ({ username , password } : Body ) =>
pipe (
E .Do,
errorsApS ( "username" , validateUsername (username)),
errorsApS ( "password" , validatePassword (password)),
);
복합적인 에러를 가진 예제를 만들어 확인해보자.
const examples : Record < string , Body > = {
valid: {
username: "username" ,
password: "password123" ,
},
[ "Has not username and password" ]: {},
[ "Too short username, not alphanumeric username" ]: {
username: "user!" ,
password: "password123" ,
},
[ "Has not password, not alphanumeric username" ]: {
username: "username!" ,
},
[ "Too long username, too long password" ]: {
username: "usernameusernameusernameusername" ,
password: "passwordpasswordpasswordpassword123" ,
},
[ "Has not username, too short password, alphabet only password" ]: {
password: "pas" ,
},
};
console. log ( R . map ( flow (validUserInput, stringify))(examples));
/* Output:
{
valid: 'Result: username is username, password is password123',
'Has not username and password': 'Error: username must be a string, password must be a string',
'Too short username, not alphanumeric username': 'Error: username is too short, username must be alphanumeric',
'Has not password, not alphanumeric username': 'Error: username must be alphanumeric, password must be a string',
'Too long username, too long password': 'Error: username is too long, password is too long',
'Has not username, too short password, alphabet only password': 'Error: username must be a string, password is too short, password has at least one letter and one number'
}
*/
문제 없이 작동하는 것을 확인할 수 있다.
최종 코드
import * as E from "fp-ts/Either" ;
import * as S from "fp-ts/string" ;
import * as T from "fp-ts/Tuple" ;
import * as A from "fp-ts/Array" ;
import * as R from "fp-ts/Record" ;
import * as Ap from "fp-ts/Apply" ;
import { pipe, apply, flow, constant, constVoid } from "fp-ts/function" ;
import { Predicate, not } from "fp-ts/Predicate" ;
import { Refinement } from "fp-ts/Refinement" ;
interface Body extends Record < string , string > {}
interface Username extends Body {
username : string ;
}
interface Password extends Body {
password : string ;
}
interface UserInput extends Username , Password {}
const minLength = ( n : number ) => ( s : string ) => s. length >= n;
const maxLength = ( n : number ) => ( s : string ) => s. length <= n;
const includes = ( s : RegExp ) => ( str : string ) => s. test (str);
const isAlphaNumeric = includes ( / ^ [a-zA-Z0-9] +$ / );
const hasAlphaAndNumeric = includes ( / ^ (?= . *? \d )(?= . *? [a-zA-Z] ) . +$ / );
const errorsApplicative = E . getApplicativeValidation ( A . getMonoid < string >());
const errorsApS = Ap. apS (errorsApplicative);
const validate =
< U , T extends U >( predAndError : [ Predicate < T > | Refinement < U , T >, string ][]) =>
( v : U ) =>
pipe (
predAndError,
A . filter ( flow ( T .fst, not, apply (v as T ))),
A . map ( T .snd),
E . fromPredicate ( A .isNonEmpty, constVoid),
E . fold ( constant ( E . of (v as T )), E .left < string[], T > )
);
const validateUsername = ( username : unknown ) =>
pipe (
username,
validate ([[ S .isString, "username must be a string" ]]),
E . flatMap (
validate ([
[ minLength ( 6 ), "username is too short" ],
[ maxLength ( 20 ), "username is too long" ],
[isAlphaNumeric, "username must be alphanumeric" ],
])
)
);
const validatePassword = flow (
validate ([[ S .isString, "password must be a string" ]]),
E . chain (
validate ([
[ minLength ( 8 ), "password is too short" ],
[ maxLength ( 20 ), "password is too long" ],
[hasAlphaAndNumeric, "password has at least one letter and one number" ],
])
)
);
const validUserInput = ({ username , password } : Body ) =>
pipe (
E .Do,
errorsApS ( "username" , validateUsername (username)),
errorsApS ( "password" , validatePassword (password))
);
const stringify = E . match < string [], UserInput , string >(
( err ) => `Error: ${ err . join ( ", " ) }` ,
({ username , password }) =>
`Result: username is ${ username }, password is ${ password }`
);
const examples : Record < string , Body > = {
valid: {
username: "username" ,
password: "password123" ,
},
[ "Has not username and password" ]: {},
[ "Too short username, not alphanumeric username" ]: {
username: "user!" ,
password: "password123" ,
},
[ "Has not password, not alphanumeric username" ]: {
username: "username!" ,
},
[ "Too long username, too long password" ]: {
username: "usernameusernameusernameusername" ,
password: "passwordpasswordpasswordpassword123" ,
},
[ "Has not username, too short password, alphabet only password" ]: {
password: "pas" ,
},
};
console. log ( R . map ( flow (validUserInput, stringify))(examples));